spb/countryatlas
Public
TypeScript 57%
Python 38.6%
JavaScript 3.6%
CSS 0.6%
1import type { Metadata } from 'next';2import Link from 'next/link';3import { notFound } from 'next/navigation';4import { t } from '@/i18n';5import { api, isNotBuilt, safe } from '@/lib/api';6import { formatDate } from '@/lib/format';7import { jsonLd, seoTitle } from '@/lib/seo';8import { routes } from '@/lib/site';9import { STORIES, storyBySlug, storyChartCount, storyIndicators } from '@/lib/stories';10import { topicById } from '@/lib/topics';11import { NotBuiltState } from '@/components/data/empty-state';12import { PageHeader } from '@/components/explore/page-header';13import { JsonLd } from '@/components/platform/json-ld';14import { loadStoryData } from '@/components/stories/resolve';15import { StoryBlocks } from '@/components/stories/story-blocks';1617export const revalidate = 3600;1819type Params = { slug: string };2021export function generateStaticParams(): Params[] {22 return STORIES.map((s) => ({ slug: s.slug }));23}2425export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {26 const { slug } = await params;27 const s = storyBySlug(slug);28 if (!s) return { title: t('stories.notFound'), robots: { index: false } };29 const title = seoTitle.story(s.title);30 const canonical = routes.story(s.slug);31 return {32 title,33 description: s.standfirst,34 alternates: { canonical },35 openGraph: { title: `${title} — ${t('site.name')}`, description: s.standfirst, url: canonical, type: 'article', publishedTime: s.published },36 twitter: { card: 'summary_large_image', title, description: s.standfirst },37 };38}3940export default async function StoryPage({ params }: { params: Promise<Params> }) {41 const { slug } = await params;42 const story = storyBySlug(slug);43 if (!story) notFound();44 let health;45 try {46 health = await api.health();47 } catch (e) {48 if (isNotBuilt(e)) return <NotBuiltState />;49 throw e;50 }51 if (health.status === 'empty') return <NotBuiltState />;52 const data = await loadStoryData(story);53 const indicators = storyIndicators(story);54 const indicatorRes = await Promise.all(indicators.map((s) => safe(api.indicatorMap(s, {}))));55 const indicatorNames = indicators.map((s, i) => ({ slug: s, name: indicatorRes[i]?.indicator.name ?? s }));56 const sources = Array.from(new Set(indicatorRes.map((m) => m?.provenance?.source_name).filter((x): x is string => !!x)));57 const others = STORIES.filter((s) => s.slug !== story.slug);58 const topics = story.topics.map((tp) => topicById(tp)?.short ?? tp);5960 return (61 <>62 <JsonLd data={[jsonLd.article({ slug: story.slug, title: story.title, description: story.standfirst, published: story.published, modified: health.built_at }), jsonLd.breadcrumbs([{ name: t('site.name'), path: '/' }, { name: t('stories.title'), path: routes.stories() }, { name: story.title, path: routes.story(story.slug) }])]} />63 <PageHeader crumbs={[{ href: routes.stories(), label: t('stories.title') }]} eyebrow={`${t('stories.eyebrow')} · ${topics.join(' · ')}`} title={story.title} lede={story.standfirst} meta={`${t('stories.charts', { n: storyChartCount(story) })} · ${t('stories.minutes', { n: Math.max(2, Math.round(storyChartCount(story) * 0.8)) })} · ${t('site.footer.refreshed', { date: formatDate(health.built_at) })}`} />6465 <article className="mt-4 md:mt-6">66 <StoryBlocks story={story} data={data} />67 </article>6869 <section className="hairline mt-12 pt-6" aria-labelledby="story-sources-h">70 <h2 id="story-sources-h" className="display text-xl text-ink md:text-2xl">71 {t('stories.sources')}72 </h2>73 <p className="mt-2 max-w-prose text-sm leading-relaxed text-ink-2">{t('stories.sourcesNote', { run: health.run_id ?? '', date: formatDate(health.built_at) })}</p>74 {sources.length ? <p className="mt-1 text-sm text-ink-2">{sources.join(' · ')}</p> : null}75 <h3 className="mt-4 text-sm font-semibold text-ink">{t('stories.indicators')}</h3>76 <ul className="mt-1 flex flex-wrap gap-1.5">77 {indicatorNames.map((i) => (78 <li key={i.slug}>79 <Link href={routes.indicator(i.slug)} className="inline-flex min-h-[36px] items-center rounded-sm border border-rule px-2.5 text-sm text-ink-2 hover:border-accent hover:text-accent">80 {i.name}81 </Link>82 </li>83 ))}84 </ul>85 <h3 className="mt-5 text-sm font-semibold text-ink">{t('stories.method')}</h3>86 <p className="mt-1 max-w-prose text-sm leading-relaxed text-ink-3">{t('stories.methodText')}</p>87 </section>8889 <section className="hairline mt-10 pt-6 pb-10" aria-labelledby="story-more-h">90 <h2 id="story-more-h" className="display text-xl text-ink md:text-2xl">91 {t('stories.more')}92 </h2>93 <ul className="mt-3 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">94 {others.map((s) => (95 <li key={s.slug} className="border-t border-rule">96 <Link href={routes.story(s.slug)} className="group flex min-h-[64px] flex-col justify-center py-3">97 <span className="text-sm font-semibold text-ink group-hover:text-accent">{s.title}</span>98 <span className="mt-0.5 line-clamp-2 text-xs text-ink-2">{s.standfirst}</span>99 </Link>100 </li>101 ))}102 </ul>103 </section>104 </>105 );106}107